之前在Day9提過使用Dependency Injection的好處之一是更容易測試,現在就是體現的時候了。
執行單元測試時會盡可能排除測試目標以外的其他class造成的影響,方式之一是將其他class模擬(mock)出來,這些模擬出來的class會呼叫method但不會真的執行method內容,這樣就能驗證method有被呼叫且method執行的結果不會影響到當前的測試,此模式稱為mocking framework。
實現mocking framework的要素之一就是DI,如果沒有套用DI的話,在constructor中實例化的物件就會是「真的(real)」,呼叫method時會真的執行完method的內容,讓測試的困難度增加。
看個簡單的例子,repository使用DI的方式從外部取得兩個constructor parameter:資料庫DAO和Retrofit service,我們用mock模擬它們,並驗證它們有執行正確的method。
當repository.search("abc")被呼叫時,用verify驗證repoDao有執行search("abc"):
@Test
public void search() {
    repository.search("abc");
    verify(repoDao).search("abc");
}
至於repoDao的search("abc")執行內容正不正確,那是DAO的Test要負責的事,在repository的test中只要確定這個method有執行就表示repository的邏輯是對的。
接下來就看怎麼實現mocking framework。
Mockito是Android中熱門的mocking framework library,官方文件也採用Mockito來做範例。
加入dependencies:
testImplementation "org.mockito:mockito-core:2.13.0"
宣告物件之後用mock(Class)初始化就好了。
@RunWith(JUnit4.class)
public class RepoRepositoryTest {
    private RepoDao repoDao;
    private GithubService githubService;
    private RepoRepository repository;
    @Before
    public void init() {
        repoDao = mock(RepoDao.class);
        githubService = mock(GithubService.class);
        repository = new RepoRepository(repoDao, githubService);
    }
}
如果Android Studio認不出mock(Class)而沒有出現import提示的話,再手動新增一下:
import static org.mockito.Mockito.mock;
@RunWith(JUnit4.class)
public class RepoRepositoryTest {
    ...
}
mock的物件可以使用Mockito一系列的驗證方式,例如開頭的範例使用verify驗證:
@RunWith(JUnit4.class)
public class RepoRepositoryTest {
    private RepoDao repoDao;
    private GithubService githubService;
    private RepoRepository repository;
    @Rule
    public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule();
    @Before
    public void init() {
        repoDao = mock(RepoDao.class);
        githubService = mock(GithubService.class);
        repository = new RepoRepository(repoDao, githubService);
    }
    @Test
    public void search() {
        repository.search("abc");
        verify(repoDao).search("abc");
    }
}
這樣就是簡單的mocking framework應用方式了。
我們的repository負責從資料庫和API取得資料,分為幾種情況:
第1種情況,只要資料庫裡的資料,先看上半段認識一下新面孔:
@Test
public void search_fromDb() {
    MutableLiveData<RepoSearchResult> dbSearchResult = new MutableLiveData<>();
    when(repoDao.search("foo")).thenReturn(dbSearchResult);
    Observer<Resource<List<Repo>>> observer = mock(Observer.class);
    repository.search("foo").observeForever(observer);
    verify(observer).onChanged(Resource.loading(null));
    verifyNoMoreInteractions(githubService);
}
這邊用到了when,用途是讓我們指定method的回傳值,when(X).thenReturn(Y)表示X執行的時候,回傳值要是Y,所以上面的寫法當repoDao.search("foo")執行的時候要回傳dbSearchResult。
接著repository.search("foo")執行時會呼叫repoDao.search("foo"),而後者的回傳值我們剛剛已經指定好是dbSearchResult了,所以observer就會在onChanged事件中收到dbSearchResult。
最後用verify驗證目前為讀取中,且githubService沒有發出API連線。
完整版,下半段驗證有撈到資料時不會發出API連線:
@Test
public void search_fromDb() {
    MutableLiveData<RepoSearchResult> dbSearchResult = new MutableLiveData<>();
    when(repoDao.search("foo")).thenReturn(dbSearchResult);
    Observer<Resource<List<Repo>>> observer = mock(Observer.class);
    repository.search("foo").observeForever(observer);
    verify(observer).onChanged(Resource.loading(null));
    verifyNoMoreInteractions(githubService);
    List<Integer> ids = Arrays.asList(1, 2);
    RepoSearchResult dbResult = new RepoSearchResult("foo", ids, 2);
    MutableLiveData<List<Repo>> repos = new MutableLiveData<>();
    when(repoDao.loadOrdered(ids)).thenReturn(repos);
    dbSearchResult.postValue(dbResult);
    List<Repo> repoList = new ArrayList<>();
    repos.postValue(repoList);
    verify(observer).onChanged(Resource.success(repoList));
    verifyNoMoreInteractions(githubService);
}
建立任意的RepoSearchResult並由dbSearchResult用postValue發出,表示資料庫有撈到資料。
當撈到資料時會執行repoDao.loadOrdered(ids),其回傳值用when設置為repos,當repos用postValue更新的時候表示發送資料庫撈到的結果List<Repo>,最後就驗證observer有收到資料且API沒有啟動。
第2種情況,資料庫找不到資料,需呼叫API連線。
@Test
public void search_fromServer() {
    MutableLiveData<RepoSearchResult> dbSearchResult = new MutableLiveData<>();
    when(repoDao.search("foo")).thenReturn(dbSearchResult);
    Observer<Resource<List<Repo>>> observer = mock(Observer.class);
    repository.search("foo").observeForever(observer);
    verify(observer).onChanged(Resource.loading(null));
    verifyNoMoreInteractions(githubService);
    MutableLiveData<ApiResponse<RepoSearchResponse>> callLiveData = new MutableLiveData<>();
    when(githubService.searchRepos("foo")).thenReturn(callLiveData);
    dbSearchResult.postValue(null);
    verify(repoDao, never()).loadOrdered(any());
    verify(githubService).searchRepos("foo");
}
前面大致相同,後面用dbSearchResult.postValue(null)表示資料庫找不到資料,接著就驗證DAO沒有動作及githubService已發出連線。
這樣的寫法跟Google sample一樣,最後驗證api有發出連線即完成;另一個sample後續還測試了儲存連線資料的部分,若要跟它一樣的話需修改目前的NetworkBoundResource,因為在saveResultAndReInit中使用new AsyncTask這樣的寫法是沒辦法用Mockito測試的,要套用DI或改成其他的切換thread方式例如AppExecutors。
最後一種情形是API連線失敗了,回傳error時的測試:
@Test
public void search_fromServer_error() {
    when(repoDao.search("foo")).thenReturn(AbsentLiveData.create());
    MutableLiveData<ApiResponse<RepoSearchResponse>> apiResponse = new MutableLiveData<>();
    when(githubService.searchRepos("foo")).thenReturn(apiResponse);
    Observer<Resource<List<Repo>>> observer = mock(Observer.class);
    repository.search("foo").observeForever(observer);
    
    apiResponse.postValue(new ApiResponse<RepoSearchResponse>(new Exception("idk")));
    verify(observer).onChanged(Resource.error(null, "idk"));
}
用when設置repoDao.search("foo")回傳AbsentLiveData,當執行repository.search("foo")時就會因為repoDao撈出的結果是null而啟動githubService.searchRepos("foo"),最後發送帶有exception的連線結果並驗證狀態為error。
GitHub source code:
https://github.com/IvanBean/ITBon2018/tree/day19-test-repository